This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / routes / [handle] / +page.ts
11 kB 342 lines
1import type { Did } from "@atcute/lexicons/syntax"; 2import { createBobbinClient } from "$lib/api/client"; 3import { fetchPage, items } from "$lib/api/pagination"; 4import { count } from "$lib/api/count"; 5import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; 6import { IdentityCache } from "$lib/api/identity"; 7import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8import { toHttpError, parallel } from "$lib/api/load"; 9import { search } from "$lib/api/search"; 10import type { BobbinContext } from "$lib/api/client"; 11import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; 12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15import type { 16 RepoCardData, 17 StringCardData, 18 PersonData, 19 VouchData, 20 StarData 21} from "$lib/components/profile/types"; 22import type { PageLoad } from "./$types"; 23 24const PAGE_LIMIT = 50; 25 26const TABS = [ 27 "overview", 28 "repos", 29 "starred", 30 "strings", 31 "followers", 32 "following", 33 "vouches" 34] as const; 35type Tab = (typeof TABS)[number]; 36 37const normalizeTab = (raw: string | null): Tab => 38 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 39 40interface ListItem { 41 uri: string; 42 value: unknown; 43} 44 45const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 46 const value = item.value as RepoRecord; 47 return { 48 rkey: rkeyFromUri(item.uri), 49 name: value.name ?? rkeyFromUri(item.uri), 50 repoDid: value.repoDid ?? "", 51 ownerHandle, 52 description: value.description, 53 knot: value.knot, 54 createdAt: value.createdAt 55 }; 56}; 57 58interface ResolveRepoCardOptions { 59 viewerStarRkeys?: ReadonlyMap<string, string>; 60} 61 62const resolveRepoCard = async ( 63 ctx: BobbinContext, 64 item: ListItem, 65 ownerHandle: string, 66 options: ResolveRepoCardOptions = {} 67): Promise<RepoCardData> => { 68 const repo = toRepoCard(item, ownerHandle); 69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 // TODO(bobbin): instead of doing this, listing repos should return star counts 71 // and most likely other stats as well. 72 const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); 73 return { 74 ...repo, 75 stars: stars.count, 76 viewerStarRkey: options.viewerStarRkeys 77 ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) 78 : undefined 79 }; 80}; 81 82const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 83 const value = item.value as ShTangledString.Main; 84 return { 85 rkey: rkeyFromUri(item.uri), 86 ownerHandle, 87 filename: value.filename, 88 description: value.description, 89 createdAt: value.createdAt, 90 lines: value.contents?.split("\n").length ?? 1 91 }; 92}; 93 94// resolve dids -> handle/avatar, deduped, preserving input order. 95const resolvePeople = async ( 96 ctx: BobbinContext, 97 dids: string[], 98 viewerDid?: string 99): Promise<PersonData[]> => { 100 const cache = new IdentityCache(ctx); 101 const unique = [...new Set(dids)]; 102 103 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 104 // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. 105 const counts = await parallel( 106 unique.reduce( 107 (acc, did) => { 108 acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) 109 .then((result) => result.count) 110 .catch(() => 0); 111 acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) 112 .then((result) => result.count) 113 .catch(() => 0); 114 return acc; 115 }, 116 {} as Record<string, Promise<number>> 117 ) 118 ); 119 120 const viewerFollowRkeys = new Map<string, string>(); 121 if (viewerDid) { 122 for await (const item of items( 123 ctx, 124 "sh.tangled.graph.listFollowsBy", 125 { subject: viewerDid as Did }, 126 { maxPages: 10 } 127 )) { 128 const value = item.value as FollowRecord; 129 viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); 130 } 131 } 132 133 const byDid = new Map<string, PersonData>(); 134 unique.forEach((did, index) => { 135 const doc = docs[index]; 136 const followers = counts[`${did}-followers`]; 137 const following = counts[`${did}-following`]; 138 const isSelf = viewerDid === did; 139 const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; 140 byDid.set( 141 did, 142 doc 143 ? { 144 did: doc.did, 145 handle: doc.handle, 146 avatar: doc.avatar, 147 followers, 148 following, 149 isSelf, 150 viewerFollowRkey 151 } 152 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 153 ); 154 }); 155 return unique.map((did) => byDid.get(did) as PersonData); 156}; 157 158const resolveVouches = async ( 159 ctx: BobbinContext, 160 items: ListItem[], 161 direction: "incoming" | "outgoing" 162): Promise<VouchData[]> => { 163 const cache = new IdentityCache(ctx); 164 return Promise.all( 165 items.map(async (item): Promise<VouchData> => { 166 const value = item.value as VouchRecord; 167 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 168 const doc = await cache.resolve(otherDid).catch(() => null); 169 return { 170 uri: item.uri, 171 did: otherDid, 172 handle: doc?.handle ?? otherDid, 173 avatar: doc?.avatar, 174 kind: value.kind === "denounce" ? "denounce" : "vouch", 175 direction, 176 reason: value.reason, 177 createdAt: value.createdAt 178 }; 179 }) 180 ); 181}; 182 183const resolveStars = async ( 184 ctx: BobbinContext, 185 items: ListItem[], 186 options: ResolveRepoCardOptions 187): Promise<StarData[]> => { 188 const cache = new IdentityCache(ctx); 189 const resolved = await Promise.all( 190 items.map(async (item): Promise<StarData | null> => { 191 const value = item.value as ShTangledFeedStar.Main; 192 const subject = value.subject; 193 if (subject && "did" in subject && subject.did) { 194 try { 195 const repo = await getRepoByRepoDid(ctx, subject.did); 196 const ownerDid = didFromUri(repo.uri); 197 const owner = await cache.resolve(ownerDid).catch(() => null); 198 return { 199 kind: "repo", 200 uri: item.uri, 201 createdAt: value.createdAt, 202 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) 203 }; 204 } catch { 205 return null; 206 } 207 } 208 if (subject && "uri" in subject && subject.uri) { 209 const ownerDid = didFromUri(subject.uri); 210 const owner = await cache.resolve(ownerDid).catch(() => null); 211 return { 212 kind: "string", 213 uri: item.uri, 214 createdAt: value.createdAt, 215 ownerHandle: owner?.handle ?? ownerDid, 216 rkey: rkeyFromUri(subject.uri) 217 }; 218 } 219 return null; 220 }) 221 ); 222 return resolved.filter((star): star is StarData => star !== null); 223}; 224 225export const load: PageLoad = async (event) => { 226 const parent = await event.parent(); 227 const tab = normalizeTab(event.url.searchParams.get("tab")); 228 229 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 230 231 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 232 const did = parent.identity.did as Did; 233 const handle = parent.identity.handle; 234 235 try { 236 switch (tab) { 237 case "repos": { 238 const q = event.url.searchParams.get("q")?.trim(); 239 const [found, viewerStarRkeys] = await Promise.all([ 240 q 241 ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( 242 (page) => page.hits 243 ) 244 : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( 245 (page) => page.items 246 ), 247 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 248 ]); 249 return { 250 tab, 251 repos: await Promise.all( 252 found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 253 ) 254 }; 255 } 256 case "strings": { 257 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 258 subject: did, 259 limit: PAGE_LIMIT 260 }); 261 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 262 } 263 case "followers": { 264 const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { 265 subject: did, 266 limit: PAGE_LIMIT 267 }); 268 const dids = page.items.map((item) => didFromUri(item.uri)); 269 return { 270 tab, 271 people: await resolvePeople(ctx, dids, parent.auth?.did) 272 }; 273 } 274 case "following": { 275 const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { 276 subject: did, 277 limit: PAGE_LIMIT 278 }); 279 const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 280 return { 281 tab, 282 people: await resolvePeople(ctx, dids, parent.auth?.did) 283 }; 284 } 285 case "vouches": { 286 const [incomingPage, outgoingPage] = await Promise.all([ 287 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), 288 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) 289 ]); 290 const [incoming, outgoing] = await Promise.all([ 291 resolveVouches(ctx, incomingPage.items, "incoming"), 292 resolveVouches(ctx, outgoingPage.items, "outgoing") 293 ]); 294 const vouches = [...incoming, ...outgoing].sort( 295 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 296 ); 297 return { 298 tab, 299 vouches, 300 isSelf: parent.auth?.did === did, 301 profileHandle: handle 302 }; 303 } 304 case "starred": { 305 const [page, viewerStarRkeys] = await Promise.all([ 306 fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), 307 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 308 ]); 309 return { 310 tab, 311 stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 312 }; 313 } 314 case "overview": 315 default: { 316 const [page, viewerStarRkeys] = await Promise.all([ 317 fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), 318 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 319 ]); 320 321 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 322 const byKey = new Map<string, ListItem>(); 323 for (const item of page.items) { 324 const value = item.value as RepoRecord; 325 if (value.repoDid) byKey.set(value.repoDid, item); 326 byKey.set(item.uri, item); 327 } 328 const pinnedItems = pinnedKeys 329 .map((key) => byKey.get(key)) 330 .filter((item): item is ListItem => item !== undefined); 331 const pinned = await Promise.all( 332 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 333 ); 334 335 return { tab: "overview" as const, overview: { pinned } }; 336 } 337 } 338 } catch (cause) { 339 console.error("Page load error:", cause); 340 toHttpError(cause, "Could not load profile data"); 341 } 342};